Skip to content

Fix stuck model switches from false end-failure in session takeover - #1308

Open
nordicnode wants to merge 1 commit into
CodebuffAI:mainfrom
nordicnode:fix/1298-model-locked-takeover
Open

Fix stuck model switches from false end-failure in session takeover#1308
nordicnode wants to merge 1 commit into
CodebuffAI:mainfrom
nordicnode:fix/1298-model-locked-takeover

Conversation

@nordicnode

@nordicnode nordicnode commented Sep 9, 2026

Copy link
Copy Markdown

Problem & Context

#1298: picking a model while the server still holds a session row can strand the user for the full hour. In the model_locked deliberate-pick takeover (cli/src/hooks/use-freebuff-session.ts), the held row was released only when it was status === 'active' and matched the lock's model. Two real shapes fail that guard with no DELETE ever attempted:

  • ended rows still inside the server's grace window (with an instance id) — the stale crashed-CLI rows this branch exists to clear, and the shape holdsLiveFreebuffSlot already treats as slot-holding;
  • rows that vanished between the lock response and the follow-up GET.

Either one made the client report "ending it failed, run /end-session" — a thing it never tried — and the user waited out the hour.

Changes Made

  • Planner planModelLockedSwitch (cli/src/utils/freebuff-session-api.ts) maps the takeover's follow-up GET row to release (active on the locked model, or ended inside grace, where the DELETE replays the refund receipt via the instance id), retry (no row left), or explain (unattributable; a different model's row is never deleted).
  • Branch runModelLockedTakeover (cli/src/hooks/use-freebuff-session.ts) holds the whole model_locked decision — the deliberate-pick guard, the follow-up GET, the DELETE, and both chat notices — with fetchHeld / releaseSlot / notify / isStale injected, returning repick | revert | stale. The tick maps that outcome onto the scheduling it already did.
  • Marker the tick's read-and-clear of the explicit-pick marker is now takeFreebuffExplicitPick(). That read-and-clear is the one-retry bound: the marker annotates exactly one response, so a lock that races back after the retry's re-POST reverts in silence instead of looping.
  • The "ending it failed" notice fires only when a read or delete was genuinely attempted. /end-session, the withdrawn-model fallback flip, and all server contracts are unchanged.

Verification

  • tsc --noEmit -p . in cli (TypeScript 5.5.4): exit 0.
  • Repo-pinned bun 1.3.14, full cli suite: 3065 pass / 18 fail at head vs 3046 pass / 18 fail at base 3f00c772a. Failing set identical — 16 packages/internal/src/env import errors (paths absent from the public snapshot) plus 2 pre-existing /reasoning assertion reds. The delta is +19 tests: 6 planner, 13 branch.
  • Targeted: bun test src/hooks/__tests__/model-locked-takeover.test.ts src/utils/__tests__/freebuff-session-api.test.ts → 28 pass / 0 fail.
  • Mutation-checked rather than merely green: twelve single-line mutations applied one at a time to runModelLockedTakeover / planModelLockedSwitch / the marker clear — retry treated as explain, the silent re-POST notices, either staleness gate dropped, the deliberate-pick guard dropped, a foreign-model row released, an ended-past-grace row released, the success and failure notices dropped, the two model labels swapped in one message, the marker no longer cleared — each turns the suite red; every one reverted.
  • Added lines are prettier-clean; the touched files' pre-existing prettier violations were left unformatted on purpose.

Porting note: runModelLockedTakeover, ModelLockedTakeoverDeps, ModelLockedTakeoverOutcome, noteFreebuffExplicitPick and takeFreebuffExplicitPick have no consumers outside use-freebuff-session.ts. They exist so the branch is reachable from a test without mounting the hook — the repo's dependency-injection-over-mock.module() rule rules out the alternative. The lines inside tick that turn an outcome into nextMethod / schedule(0) are unchanged from the current code and are still asserted by reading, not by a test.

This fixes the client-side stuck state and the false report in #1298; the server's row lifecycle itself is outside this repo. Related to #1298.

@codebuff-team

Copy link
Copy Markdown
Contributor

Nice work. Pulling the takeover decision out into planModelLockedSwitch in freebuff-session-api.ts is the right move — it turns an untestable inline conditional in use-freebuff-session.ts into a pure function with six focused unit tests covering the actual failure modes (ended-but-in-grace, ended-past-grace, swept row, mismatched model, unreadable row). That's exactly the kind of change that's easy to port by hand into a private tree, which is what this mirror needs.

The logic itself holds up: treating ended rows with an instanceId as still holding a slot (matching what holdsLiveFreebuffSlot already does elsewhere) closes a real gap, and refusing to delete a row for a different model preserves the existing safety property. The one-shot retry via the consumed explicit-pick marker is a sensible way to avoid a loop when the lock races and releases itself before the follow-up GET lands.

One gap: there's no test exercising the hook branch itself — the lockRaced retry path, the one-retry-then-revert behavior, and the message-only-on-explain claim from the PR description are all asserted by reasoning about the diff rather than by a test against use-freebuff-session.ts. Given how async and store-coupled that file is, I understand why you stopped at the planner boundary, but a maintainer will likely want at least a smoke test on the hook's model_locked branch (mocking callFreebuffSession) before merging, since that's where the retry/no-loop guarantee actually lives, not in the pure function.

Good scope discipline otherwise — no touches to forbidden paths, no drive-by reformatting of the pre-existing prettier violations you called out.

@codebuff-team codebuff-team added bot:triaged Classified by the community triage bot pr:port-candidate Worth porting into the private source tree labels Sep 10, 2026
The model_locked takeover only released rows that were active on the locked
model, so ended-within-grace rows (the stale rows the branch exists for) and
already-released rows produced an explanation claiming the end had failed when
no DELETE was ever attempted, locking the user out of their pick for the
session hour (CodebuffAI#1298).

Route the follow-up GET through a pure planner that separates release (live row
attributable to the lock) from retry (row already gone, re-POST the consumed
pick once) from explain (real read/delete failure or an unattributable row), and
act on it from the branch itself. The branch is extracted as
runModelLockedTakeover with its collaborators injected, so the retry path, the
one-retry bound, and the message-only-on-attempt behavior are covered by tests
that drive production code instead of reasoning about the diff.
@nordicnode
nordicnode force-pushed the fix/1298-model-locked-takeover branch from 79715fd to aa465f9 Compare September 10, 2026 17:34
@nordicnode

Copy link
Copy Markdown
Author

Follow-up on the hook-branch ask: the branch is now production code with its collaborators injected, so it is testable without mounting the hook.

runModelLockedTakeover(explicitPickModel, lockedModel, deps) (cli/src/hooks/use-freebuff-session.ts) takes the whole model_locked branch — deliberate-pick guard, follow-up GET, planner call, DELETE, and both notices — and returns repick | revert | stale. tick keeps the four wiring lines that turn that outcome into nextMethod / schedule(0).

cli/src/hooks/__tests__/model-locked-takeover.test.ts (13 tests; no stores, timers, spies, or mock.module) covers the three points:

  • lockRaced: {status:'none'} and {status:'ended'} with no instance id both return repick with zero notices and zero DELETEs; the active row and the grace-window ended row return repick only after releaseSlot receives exactly that row.
  • one retry: the marker read-and-clear moved into takeFreebuffExplicitPick(), so the retry test drives rounds through production consumption rather than a test-side simulation — ['repick','revert'], one follow-up GET, no notice. Dropping the pendingExplicitPickModel = null line turns both that test and the direct accessor test red.
  • message only on a real attempt: both notices are asserted as full strings with both display names in their slots (swapping labels.current / labels.requested in one template goes red), and the failure wording is asserted on exactly the four explain shapes — foreign-model row, superseded, throwing read, refused DELETE — with silence asserted on repick, stale, background rejoin, and a pick naming the locked model.

Gates, repo-pinned bun 1.3.14: tsc --noEmit -p cli exit 0. Full cli suite 3065 pass / 18 fail at head vs 3046 pass / 18 fail at base 3f00c772a, failing set identical — 16 packages/internal/src/env import errors (paths absent from the snapshot) plus 2 pre-existing /reasoning reds; the delta is the 19 added tests. QueuePanel's two render tests flake (1 red in 5 isolated reruns at base), which is why that count moves run to run. Twelve single-line mutations of the branch, the planner, and the marker clear each turn the suite red; all reverted.

Two known limits, stated rather than papered over: the four wiring lines in tick are still asserted by reading — reaching them needs the hook mounted (React, fake timers, mock.module() on the session client), which the DI rule rules out, and they are byte-identical to what you reviewed in the first commit — and startFreebuffSession is not used to arm the marker in tests because it persists the model preference to disk. Branch squashed to aa465f973 and rebased onto 3f00c772a.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:triaged Classified by the community triage bot pr:port-candidate Worth porting into the private source tree

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants